Day 09 那兩根 K 線,到今天第三次出場,因為它們正好卡在今天要問的問題上:
| 開盤時間(UTC) | 成交量(BTC) | 成交額(USDT) | 成交筆數 |
|---|---|---|---|
| 2026-07-15 20:39 | 12.755 | 829,187 | 581 |
| 2026-07-15 07:46 | 12.883 | 832,009 | 2,426 |
成交量差 1%,成交筆數差 4.2 倍。
現在把問題往前推一步:12.755 顆 BTC 這個數字本身,算多還是算少?
它顯然不能單獨回答。同一天的最冷清那一分鐘成交 0.257 顆,最熱鬧那一分鐘成交 204.6 顆——12.755 落在中間偏低。但如果換成某個小幣,一分鐘成交 12 顆可能是它一整天的量。而就算還是 BTC,凌晨五點的 12 顆跟下午兩點的 12 顆也不是同一件事。
所以「活躍度」這種特徵不能輸出絕對值,要輸出相對於自己平常的程度。標準做法是 z-score:
z = (現在 − 平常) / 平常的標準差
聽起來很簡單,一行 pandas 就寫完了。但這一行有兩個地方寫錯不會報錯,而且兩個都會讓回測看起來比實際好。今天大半篇在處理那兩個地方,以及它們背後那個共同的形狀。
另外今天還要補一個 Day 24 會用到的東西:ATR。它跟活躍度是同一件事的另一面——活躍度量「多熱」,ATR 量「價格實際走了多少」,而它是決定停損距離的依據。
波動率(volatility)量的是價格變動的幅度,不是方向。
最常見的一種叫已實現波動率:把一段期間內每根 K 線的報酬變異累加起來,得到「這段時間價格總共抖了多少」。
實作上只有一個選擇要知道:報酬用對數報酬而不是百分比報酬。理由是對數報酬可加——把 1 分鐘的報酬換算成 1 小時的時候直接相加就對了,不會累積複利的偏差。這條在後面每一個換粒度的地方都成立,值得記住。
波動率跟成交量是兩個不同的東西,而它們會分家。兩種分家的方式都很常見:
第二種比第一種危險得多,而只看成交量完全看不出來。
值得把這兩種情況跟 Day 10 接起來:它們其實是深度在成交端留下的影子。深度厚的時候,同樣的主動買賣量推不動價格,於是成交量高而波動低;深度薄的時候,一張小單就吃穿好幾檔,於是成交量低而波動高。掛單簿只有我們自己錄的那 45 分鐘,但「成交量與波動率的比值」在完整的歷史上都算得出來——它是流動性的一個間接代理,而且不必掛單簿資料。Day 13 的假突破判斷會用到這個關係。
ATR(Average True Range,平均真實區間)是另一種波動率,用價格單位表示而不是百分比。
它的核心是「真實區間」:
TR = max(高 − 低, |高 − 前收|, |低 − 前收|)
第一項是這根 K 線自己的範圍。後兩項處理的是跳空:如果這一根整根跳到前一根之上,「高 − 低」可能很小,但實際的價格移動很大。
換句話說,真實區間問的不是「這根 K 線畫出來有多長」,而是「從上一次收盤到現在,價格實際走過的範圍至少有多大」。這個定義把 K 線之間的斷點也算進去,所以它對「拿它決定停損」這個用途比單純的高低差合適——停損要防的是價格跑掉,而價格不會因為跨了一根 K 線就停下來。
加密貨幣 24/7 不休市,所以跳空比股市少得多——沒有隔夜、沒有週末休市。但快速行情裡一分鐘跳 1% 的事還是會發生,那時候只看「高 − 低」會低估波動。
ATR 之所以今天就要建起來,是因為 Day 24 決定停損距離時要用它:「停損放在 2 個 ATR 之外」是一個會隨市場波動自動伸縮的規則,而「停損放在 200 USDT 之外」不是。這個差別是整個系列在談「參數要無單位」的同一件事,只是換到了風險管理那一側。
加密貨幣沒有開盤收盤,理論上任何時間都一樣。實際上完全不是。
實測 2025-01-01 到 2026-07-31 的 13,848 根 1 小時 K 線,把成交筆數按 UTC 鐘點分組、除以整體平均:
14:00 UTC 是美國股市開盤前後(美東早上 9 到 10 點),05:00 UTC 是亞洲的清晨、歐洲的深夜。也就是說即使市場不休息,人會休息,而且參與者的地理分布留下了明顯的痕跡。
這種結構有一個名字叫季節性——一種可預測的、週期性的成分。而可預測的成分在特徵工程裡有一個固定的處理方式:把它除掉,而不是把它當訊號。
理由是這樣:如果一個特徵在下午兩點的值系統性地比凌晨五點高,那麼任何「這個特徵很高就進場」的策略,實際上都摻雜了一個「在下午兩點進場」的規則。策略回測出來的績效裡,有多少來自那個特徵、有多少只是來自時段,分不出來。把季節性除掉之後剩下的殘差,才是「相對於這個時段該有的樣子,現在不尋常的部分」。
所以基準怎麼取,是今天的核心問題。
z-score 的形式很單純,但它背後有兩個假設,而市場資料兩個都不完全滿足。
第一個假設是「有一個穩定的平常」。 減掉平均、除以標準差,前提是那個平均與標準差描述的是同一個分布。市場不是——2025 年初的成交筆數水準跟 2026 年中不一樣,牛市與盤整期的活躍度基準也不一樣。這就是為什麼基準要滾動或逐步展開,而不是拿整段樣本算一次:後者等於宣稱這一年半的市場是同一個分布。
第二個假設是「分布大致對稱、尾巴不厚」。 這個假設不成立的後果會在今天的實測數字上直接看到:z-score 超過 +2 的比例,如果是常態分布應該是 2.28%,實測是 5.86% 與 4.59%。活躍度的分布是右偏的——它有下限(成交筆數不能是負的)沒有上限,暴量可以是平常的一百倍,冷清最多就是趨近於零。
這一點跟 Day 11 講「標準差是一個尺度,不是一個機率」是同一句話。標準化真正買到的是可比性:不同交易對、不同時段的活躍度可以放在同一條軸上比較。它沒有買到機率解釋,所以「z > 2」是一個要回頭數次數才知道多罕見的門檻,不是查表就有答案的東西。
知道這兩個假設不成立,還是可以用 z-score,只是要知道它降級成什麼:它是一個粗略的排名代理,而不是一個有機率意義的統計量。
既然要跟「平常」比,「平常」的範圍就是一個要決定的事,而它有很多層可選:
直覺會想:越細越準,因為比較的對象越像。但這裡有一個硬性的代價——基準越細,可用的樣本越少。
一年半的 1 小時資料有 13,848 根。切成 24 個鐘點,每個鐘點有 577 個樣本,還算充足。再切星期幾,每格只剩 82 個。再往下切月份就只剩個位數,那時候算出來的「平常」本身就非常不穩,z-score 的分母抖動會比分子還大。
這是估計上的一個標準取捨:粗的基準有系統性偏差(它把不同時段混在一起),細的基準有估計誤差(樣本太少)。沒有一個普遍正確的答案,只有「這份資料撐得住多細」這個問題,而它是可以用樣本數算出來的。
今天實作兩種(滾動與鐘點),理由是它們回答不同的問題而不是一個比另一個好:
星期 × 鐘點那一層今天只拿來畫圖,不拿來當基準——82 個樣本畫熱力圖夠了,當 z-score 的分母不夠。
今天要處理的兩個陷阱,本質上是同一類問題的兩個實例,值得先把這一類問題的形狀講清楚。
資訊洩漏指的是:計算某個時點的特徵時,用到了那個時點還不該知道的資訊。它有三種常見的形態,嚴重程度遞減、隱蔽程度遞增:
一、直接用未來的值。 最明顯的一種,Day 04 講交叉訊號時處理過,Day 10 驗證預測力時也再三強調 shift(-1) 只能出現在報酬那一側。這種錯誤通常會讓結果好到不合理,反而容易被抓到。
二、把當下混進歷史基準。 當根的成交筆數在收盤時確實已經知道了,所以嚴格說它不是「未來」。問題在於它被放進了「歷史基準」這個角色,而基準的定義應該是「在這根之前,正常的樣子」。混進當根之後,那個基準就不再是一個獨立的參考點——極端值會自己把自己的門檻抬高。
三、用整段樣本估參數。 這一種最隱蔽,因為程式碼裡完全看不到 shift(-1) 這種明顯的往前看。它長成一個很自然的 groupby(...).transform("mean"),或是一次算完整段的平均與標準差再拿去標準化。可是「整段」包含了未來,所以 2026-03-01 那天的基準裡含著 2026-08 的資料。
第三種在回測上騙人的程度最高,因為它的偏差方向很微妙:它讓極端事件變得不極端。 一次真實的暴量會把自己算進標準差裡,於是它的分數被壓下來;而所有平凡的時點因為基準被那次暴量撐大,分數也被壓下來一點。整體看起來就是「這個特徵很穩定,沒有離譜的值」——這正是拿去畫圖時最讓人放心的樣子。
今天的兩個陷阱分別是第二種與第三種。它們的共同點:寫錯不會報錯,畫出來的圖很正常,而且回測結果會比實際好。
今天有兩個互不相干的東西要算:活躍度是「比平常熱多少」,ATR 是「價格實際走了多少」。前者佔掉大半篇幅,因為它有兩個會安靜出錯的地方。
活躍度:
ATR:
收尾:
第 4 步那張倍數表要能單獨取用,因為報表印出來的「最冷清 05:00、最熱鬧 14:00」跟圖上的顏色必須來自同一份計算,否則會出現「圖上是這樣、文字寫那樣」的落差。
「活躍」不是一個量,是三個。專案裡它是一個值(ActivityMeasure)而不是三個特徵類別,因為它們的計算流程完全相同(取一欄、算 z-score),差別只在取哪一欄——跟 Day 11 的 PriceSource 是同一個模式。
前面「波動率與成交量會分家」那一節說的就是第三個角度與前兩個的差別。三個一起看才完整,Day 09 那兩根 K 線是最好的例子:成交量幾乎一樣,成交筆數差 4.2 倍。
取欄位時有一個型別上的小麻煩:Day 02 把 trade_count 訂成可空的 Int64(理由是 REST 那條路徑不提供這一欄),而可空整數不能直接參與浮點運算。所以要先過 Float64 再到 float64,中間那一步是為了讓 NA 變成 NaN 而不是丟例外。
z-score 的分子是「現在 − 平常」。直覺的寫法是這樣:
history = values.rolling(window)
z = (values - history.mean()) / history.std()
rolling(window) 包含當根,所以這就是前一節說的第二種洩漏。一根暴量的 K 線會被算進自己的「平常」裡,把平均與標準差都拉高,於是它自己的 z-score 被系統性低估。
正確的寫法是先 shift(1):
# quantbot/domain/features/trading_activity.py
def _rolling_z_score(self, values: pd.Series) -> pd.Series:
"""跟最近 window 根比。
**視窗要排除當根**:shift(1) 之後再 rolling。不排除的話,當根自己會被算進
「平常」的平均與標準差裡,於是一根暴量的 K 線會自己把基準拉高,z-score
因此被系統性低估。這是未來函數的近親——不是偷看未來,是把當下混進歷史。
"""
history = values.shift(1).rolling(self.window)
deviation = history.std()
return (values - history.mean()) / deviation.where(deviation > 0)
差多少可以測:用一段平穩的資料加最後一根三倍暴量,排除當根的分數是含當根的兩倍以上。這個差距的方向永遠一致(含當根一定低估),所以它不是雜訊,是偏差。
順帶提一件測試資料的事,因為它是實際踩到的。第一版的測試資料用完全固定的序列([1000] * 30),結果全部失敗——標準差是 0,z-score 沒有定義。測試資料要有一點自然的起伏才測得到真的東西:一個完美平穩的市場不存在,拿它當測試資料,測到的是一個永遠不會發生的情況。
前面量到時段節奏差 2.71 倍,所以「跟同一個鐘點比」是必要的。這件事寫成一行非常誘人:
z = (values - values.groupby(hour).transform("mean")) / values.groupby(hour).transform("std")
這正是第三種洩漏。transform("mean") 用的是整段樣本的同鐘點平均,所以在 2026-03-01 那一天,鐘點 3 的基準裡含著 2026-08 的資料。
正確的寫法是每個鐘點各自往前累積自己的歷史:
# quantbot/domain/features/trading_activity.py
def _hour_of_day_z_score(self, values: pd.Series) -> pd.Series:
"""跟同一個鐘點的歷史比,而且只用**過去**的同鐘點資料。
直覺的寫法是 values.groupby(hour).transform("mean"),一行就好。但那個平均
用了整段樣本,包含未來——在 2026-03-01 那天,基準裡含著 2026-08 的資料。
回測時這種寫法會讓活躍度過濾條件看起來特別靈,因為它知道後面會發生什麼。
正確的寫法是 shift(1) 之後 expanding():每個鐘點各自往前累積自己的歷史。
代價是前面幾天沒有值(每個鐘點都要先看過至少兩次),這是應該付的代價。
"""
hour = pd.Series(pd.DatetimeIndex(values.index).hour, index=values.index)
grouped = values.groupby(hour)
history = grouped.shift(1)
mean = history.groupby(hour).expanding().mean().reset_index(level=0, drop=True)
deviation = (
history.groupby(hour).expanding().std().reset_index(level=0, drop=True)
)
return (values - mean) / deviation.where(deviation > 0)
groupby(hour).shift(1) 是在每個鐘點的組內往後挪一格,所以鐘點 3 的第 n 天看到的是鐘點 3 的第 n−1 天,而不是前一個小時。expanding() 再往前累積全部歷史。最後的 reset_index(level=0, drop=True) 是為了把 groupby().expanding() 多加出來的那一層索引拿掉,讓結果對得回原本的時間索引。
錯誤的版本會錯多少?做一個極端但乾淨的實驗:20 天的每小時資料,鐘點 3 一直很冷清(100 筆上下),最後一天的鐘點 3 突然暴量到 10,000。
一個真實發生的 100 倍暴量,被壓成「有點不尋常」。這正是前面說的「它讓極端事件變得不極端」——而且越極端的事件被壓得越厲害,所以看圖的時候會覺得這個特徵蠻穩定的。
Feature 協定要求每個特徵回答 warmup_bar_count。滾動基準很好答(一個視窗);鐘點基準答不出來。
原因是暖機期在這裡不是根數的問題,是次數的問題:每個鐘點各自需要看過 window 次,而一天只出現一次某個鐘點,所以真正的暖機是 window 天。要換算成根數就得知道「一天幾根」,而那取決於 timeframe,特徵類別不知道。
處理方式是回一個誠實的下限(window),真正的暖機期由「第一個非 NaN 出現在哪裡」決定。這是介面設計上的一個真實限制,寫出來比硬湊一個數字好。Day 15 的管線因此不能只信這個數字——它要同時用 NaN 來切,而那個設計就是從今天這個限制長出來的。
實測 5 天的每小時資料:鐘點基準的前 48 根(前兩天)全是 NaN,因為每個鐘點都要先出現至少兩次才有標準差。
Day 04 訂的 Indicator 是一個 ABC,compute() 會先把 self.column 那一欄取出來、轉成 float64,再交給子類別的 _compute()。SMA、EMA、RSI 三個都只吃一欄(收盤價),所以那個契約很合身。
ATR 吃三欄。硬要塞進 Indicator 只有兩條路:破壞它的契約(讓 _compute 收整張表),或在 ATR 裡繞過基底類別自己去拿資料。前者會影響三個已經寫好的指標,後者讓繼承變成裝飾。所以它是 Feature。
這個判斷的依據不是「哪個分類比較漂亮」,而是「哪一個不用改已經寫好的東西」。Day 10 訂 Feature 的時候特別提過 Indicator 是 ABC(有共用實作)而 Feature 是 Protocol(沒有),今天就是那個區分第一次派上用場:ATR 跟 OBI 之間沒有一行共用實作,所以 Protocol 是對的。
平滑則直接重用 Day 06 為 RSI 寫的 WilderSmoother。Wilder 在同一篇文章裡提出 RSI 與 ATR,兩者用的是同一種平滑(α = 1/n),所以這裡 import 那個類別而不是再寫一份 ewm(alpha=1/period, adjust=False)。Day 06 花了一整節說明那一行有多容易寫錯(α 寫成 2/(n+1) 的話中位數差 4.94 個 RSI 點),複製它等於把那個陷阱複製一份。
# quantbot/domain/features/average_true_range.py
@staticmethod
def true_range(candles: pd.DataFrame) -> pd.Series:
"""三個候選取最大值,全部向量化。
第 0 筆是 NaN 而不是「高 − 低」:沒有前一根的收盤價,所以那兩個跳空項
沒有定義。填成「高 − 低」會讓第一根的 TR 系統性偏小,而那個偏差會被
Wilder 平滑一路帶進暖機期。WilderSmoother 的輸入慣例正是第 0 筆為 NaN。
"""
previous_close = candles["close"].shift(1)
candidates = pd.concat(
{
"high_low": candles["high"] - candles["low"],
"high_close": (candles["high"] - previous_close).abs(),
"low_close": (candles["low"] - previous_close).abs(),
},
axis=1,
)
# skipna=False 是必要的:第 0 筆只有 high_low 有值,用預設的 skipna=True
# 會挑出那個值,於是第 0 筆變成「高 − 低」而不是 NaN
return candidates.max(axis=1, skipna=False).rename("true_range")
skipna=False 那一行是這段最容易漏的地方。max(axis=1) 預設會跳過 NaN,所以第 0 筆會挑出唯一有值的 high_low,於是第一筆 TR 變成「高 − 低」——一個系統性偏小的值,而它會被 Wilder 平滑一路帶進整個暖機期。
不少現成的 ATR 實作就是這樣寫的,所以跨實作對數字時暖機期會對不上。這跟 Day 06 發現 pandas-ta 的 RSI 在暖機期跟教科書定義不一致是同一類問題:遞迴指標的起始值選擇會影響前面幾百根。
驗證方式沿用 Day 05、Day 06 建立的習慣:照 Wilder 的定義寫一份逐根迴圈的對照組(只在測試裡用,NEVER 進正式路徑),600 根隨機序列上兩者誤差在 1e-9 以內。
還有一件事要寫成測試而不是註解,因為 Day 24 要靠它:ATR 的單位是價格,不是百分比。 兩段形狀相同、價位差一千倍的資料,ATR 也差一千倍。所以「停損放在 2 個 ATR 之外」是一個可以跨交易對用的規則,而「ATR 大於 300 就不進場」不是——後者在 BTC 上是一個門檻,在 ETH 上是一道永遠不會被觸發的牆。
今天用的是 1 小時線、跨一年半,粒度與範圍都跟前面幾天不一樣,所以先把資料補進來。Day 03 那支回補指令換個 --timeframe 就能用,--store 讓它除了落地 parquet 也寫進 candles:
uv run python -m quantbot.entrypoints.backfill_command \
--symbol BTC/USDT --market spot --timeframe 1h \
--start 2025-01-01 --end 2026-08-01 --store
13848 根,缺 0 根,覆蓋率 100.0000%
寫入 candles:新增 13848 列
「缺 0 根、覆蓋率 100%」是 Day 08 那套完整性檢查順手給的,而今天特別值得看一眼:時段節奏那張表是按鐘點分組取平均,某個鐘點少了幾根,那一格的倍數就會偏掉,而它不會報錯,只會給出一個看起來很正常的數字。已經在跑 Day 08 那條每天自己跑的 pipeline 的話,這一步會顯示「新增 0 列」,那也是對的——代表資料早就補齊了。
資料備妥之後:
uv run python -m quantbot.entrypoints.activity_command \
--symbol BTC/USDT --market spot --timeframe 1h \
--start 2025-01-01 --end 2026-08-01
13,848 根 K 線:2025-01-01 00:00:00+00:00 → 2026-07-31 23:00:00+00:00
成交筆數的時段節奏(相對於整體平均)
最冷清:05:00 UTC,0.69 倍
最熱鬧:14:00 UTC,1.88 倍
最熱與最冷相差 2.71 倍
同一個絕對值在不同基準下的分數(最後一根)
rolling(168):-0.24(可用 13,680 根,超過 +2 的有 5.86%)
hour_of_day:-0.37(可用 13,800 根,超過 +2 的有 4.59%)
ATR(14):293.05 USDT(收盤價的 0.47%)
那兩個「可用根數」就是暖機期的樣子,而它們不一樣長:滾動基準要等視窗滿(預設 168 根,一週),所以少了 168 根;鐘點基準是每個鐘點各自累積歷史,每個鐘點都要先出現幾次才有標準差,24 個鐘點加起來少了 48 根。報表把可用根數印出來,就是因為它跟宣告的暖機期不一定一樣,而那個差別 Day 15 的管線要處理。
(另外兩件小事:2.71 倍是用未四捨五入的倍數算的,拿印出來的 1.88 除以 0.69 會得到 2.72;ATR 那個 0.47% 是拿 293.05 對照那一段最後的收盤價 62,920.68。)
幾件事值得讀。
時段節奏是真的,而且不小。 2.71 倍不是統計上的細微差異——凌晨五點的正常成交筆數,在下午兩點會被當成極度冷清。任何用絕對門檻寫的活躍度條件(「成交筆數超過 5,000 才進場」)在這種結構下等於一個時段過濾器,而寫的人可能沒有意識到。
兩種基準給出不同的答案。 最後一根在滾動基準下是 −0.24,在鐘點基準下是 −0.37。兩個都對,它們回答的是不同的問題:滾動基準說「比最近一週稍微冷」,鐘點基準說「比同一個鐘點的歷史更冷一些」。前面那節的取捨在這裡有了具體的數字——選哪一個不是精確度的問題,是問題本身的問題。
觸發頻率也不同:超過 +2 的比例是 5.86% 對 4.59%。這正是前面說的第二個假設不成立的證據:常態分布會給 2.28%,實測是兩倍以上。所以「z-score > 2」不是「百分之二的罕見事件」,實測是百分之五到六,而這件事會直接改變一個以它為條件的策略的交易次數。
ATR 是收盤價的 0.47%。 1 小時線上,BTC 的平均真實區間大約是價格的半個百分點。這個數字在 Day 24 會直接變成停損距離:2 個 ATR 大約是 1%。
時段節奏有兩個週期疊在一起——一天之內的鐘點節奏,一週之內的星期節奏。折線圖只能表達一個,另一個會被攤平,所以用熱力圖:橫軸鐘點、縱軸星期幾,格子裡放「該時段的平均值除以整體平均」。
放倍數而不是原始值,是為了讓這張圖換一個交易對還能用——原始值的色階會被幣價綁住,倍數是無單位的。這跟前面幾天反覆在做的標準化是同一個動作,只是這次是為了讓圖可重用。
那張倍數表在專案裡是一個可以單獨取用、單獨測試的方法,不是埋在畫圖流程裡的中間變數。前面那支指令印出的「最冷清 05:00、最熱鬧 14:00」就是從這張表算的——圖與數字用的是同一份計算,不會出現「圖上看起來是這樣、文字寫的是那樣」的落差。
打開產出的 html,會看到週末整片偏冷、平日下午整片偏熱,而 14:00 那一欄從週一到週五都是最深的紅色。這張圖沒有交易訊號,它的用途是讓「時段節奏」從一句話變成一個看得到的結構,之後把它寫進過濾條件時才知道自己在濾什麼。
quantbot/
├── domain/
│ ├── values/
│ │ ├── activity_measure.py 今天:三個角度
│ │ └── activity_baseline.py 今天:ROLLING / HOUR_OF_DAY
│ └── features/
│ ├── trading_activity.py 今天:z-score
│ └── average_true_range.py 今天:ATR(是 Feature 不是 Indicator)
├── infrastructure/charting/
│ └── plotly_activity_heatmap_renderer.py 今天
├── entrypoints/activity_command.py 今天
└── tests/domain/features/
├── test_trading_activity.py 今天
└── test_average_true_range.py 今天
七項全過才算完成:
uv run pytest tests/domain/features/test_trading_activity.py 全綠,包含「排除當根」與「鐘點基準只用過去」兩個測試。這兩個是今天的重點,它們守的都是不會報錯的錯誤。排除當根那一個要斷言正確版本的分數是含當根版本的兩倍以上。transform("mean") 版本小於 5。uv run pytest tests/domain/features/test_average_true_range.py 全綠,含真實區間第一筆是 NaN、跳空被算進去、與迴圈版對照組誤差小於 1e-9、ATR 是價格單位(價位差一千倍則 ATR 差一千倍)四項。backfill_command 把 1 小時線補到庫裡(--timeframe 1h --start 2025-01-01 --end 2026-08-01 --store),確認缺 0 根;再跑 uv run python -m quantbot.entrypoints.activity_command --symbol BTC/USDT --market spot --timeframe 1h --start 2025-01-01 --end 2026-08-01,印出時段節奏(最冷清與最熱鬧相差 2.7 倍上下)、兩種基準的分數與觸發比例、以及 ATR。缺漏必須是 0——少幾根 K 線不會讓這支指令失敗,只會讓那幾個鐘點的倍數偏掉。notebooks/day12-spot_BTCUSDT_1h-activity.html:週末整片偏冷、平日 14:00 那一欄最深,色階以 1.0 為中心。uv run mypy quantbot 與 uv run lint-imports 全過。第 2 項是今天最有價值的一項。那兩個數字(100 對 5)是同一份資料、同一個問題、兩種寫法,而錯的那種寫法只有一行、看起來更乾淨。
免責聲明:本文為程式與資料工程的技術分享,所有數字皆為教學範例,不構成投資建議;活躍度與 ATR 都是對已發生行情的描述,不預測後續走勢。
到今天為止的五個東西(三個指標、OBI、VWAP、活躍度、ATR)都是時間序列特徵:每一根 K 線都有一個值,值的意義是連續的。
明天要做的是不一樣的形狀:事件。
假突破是新手最常虧錢的地方之一——價格衝破前高,看起來要走了,然後馬上被打回來。「衝破前高」不是一個連續的量,它是一個在某幾根 K 線上發生、其餘時候不發生的事件。而事件式特徵有一個時間序列特徵沒有的陷阱:「前高」這個東西非常容易在計算時把當根算進去,也就是今天那個第二種洩漏,只是這次它藏在 rolling().max() 裡。
Day 13 會定義一個真假突破的判斷,並用歷史資料統計兩者的比例與特徵分布。也會示範事件式特徵怎麼跟時間序列特徵接在同一張表上——那是 Day 15 的管線要處理的最後一種形狀。
trade_count 與 taker_buy_base_volume 欄位定義 — Binance Spot API Documentation, Market Data Endpoints
groupby(...).expanding() 的語意與它多出來的那一層索引 — pandas documentation, Windowing operations
DataFrame.max 的 skipna 預設為 True,這是真實區間第一筆會被填成「高減低」的原因 — pandas documentation, DataFrame.max
pivot_table 做二維聚合 — pandas documentation, pivot_table